昨天我們把考題規格化,建立了機器可讀的評估資料集 data/eval_cases.json。
有了題目和標準答案後,下一個問題是:怎麼用程式量化「搜尋得好不好」?
如果搜尋回傳前 5 筆結果:
如果只算「有沒有找到」,案例 A 和 B 都算成功,但對於後面要閱讀 Evidence 的 LLM 或工程師來說,體驗與 Token 消耗有著天壤之別。今天我們要實作資訊檢索領域最經典的三個指標:Hit@k、MRR 與 Precision@k。
app/evaluation.py我們在 app/evaluation.py 中實作不依賴任何外部套件的指標計算器:
# app/evaluation.py (擴充指標計算)
from typing import List, Dict, Any, Set
class RetrievalMetrics:
@staticmethod
def calculate_hit_at_k(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
"""前 k 筆是否至少命中一個預期檔案"""
top_k = actual_paths[:k]
for path in top_k:
if path in expected_paths:
return 1.0
return 0.0
@staticmethod
def calculate_mrr(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
"""計算前 k 筆第一個命中的倒數名次 (Reciprocal Rank)"""
top_k = actual_paths[:k]
for rank, path in enumerate(top_k, start=1):
if path in expected_paths:
return 1.0 / rank
return 0.0
@staticmethod
def calculate_precision_at_k(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
"""計算前 k 筆中正確檔案所佔的比例"""
top_k = actual_paths[:k]
if not top_k:
return 0.0
relevant_count = sum(1 for p in top_k if p in expected_paths)
return relevant_count / min(len(top_k), k)
@classmethod
def evaluate_query(cls, actual_results: List[Dict[str, Any]], expected_paths: List[str], k: int = 5) -> Dict[str, float]:
"""對單一題目的檢索結果計算完整指標"""
actual_paths = [item["path"] for item in actual_results]
expected_set = set(expected_paths)
return {
f"hit@{k}": cls.calculate_hit_at_k(actual_paths, expected_set, k),
f"mrr@{k}": cls.calculate_mrr(actual_paths, expected_set, k),
f"p@{k}": cls.calculate_precision_at_k(actual_paths, expected_set, k)
}
tests/unit/test_metrics.py在 tests/unit/test_metrics.py 驗證不同名次下的分數計算正確性:
# tests/unit/test_metrics.py
from app.evaluation import RetrievalMetrics
def test_metrics_calculation_first_rank():
# 正確答案出現在第 1 筆
actual = [{"path": "src/build_index.py"}, {"path": "src/other.py"}]
expected = ["src/build_index.py"]
res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
assert res["hit@5"] == 1.0
assert res["mrr@5"] == 1.0
assert res["p@5"] == 0.5 # 2 筆中有 1 筆命中
def test_metrics_calculation_lower_rank():
# 正確答案出現在第 2 筆
actual = [{"path": "src/noise.py"}, {"path": "src/build_index.py"}]
expected = ["src/build_index.py"]
res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
assert res["hit@5"] == 1.0
assert res["mrr@5"] == 0.5 # 1 / 2
assert res["p@5"] == 0.5
def test_metrics_calculation_miss():
# 前 5 筆完全沒命中
actual = [{"path": "src/wrong1.py"}, {"path": "src/wrong2.py"}]
expected = ["src/target.py"]
res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
assert res["hit@5"] == 0.0
assert res["mrr@5"] == 0.0
assert res["p@5"] == 0.0
執行測試確認通過:
uv run pytest tests/unit/test_metrics.py -v
tests/unit/test_metrics.py::test_metrics_calculation_first_rank PASSED [ 33%]
tests/unit/test_metrics.py::test_metrics_calculation_lower_rank PASSED [ 66%]
tests/unit/test_metrics.py::test_metrics_calculation_miss PASSED [100%]
============================== 3 passed in 0.04s ==============================
利用昨天載入的題目,模擬跑一次測試並印出客觀分數:
# scripts/eval_demo.py
from app.evaluation import RetrievalMetrics
mock_results = [
{"path": "src/build_index.py"},
{"path": "src/rag_common.py"},
{"path": "src/rag_chat.py"}
]
expected = ["src/rag_common.py"]
scores = RetrievalMetrics.evaluate_query(mock_results, expected, k=3)
print(f"評估得分: Hit@3={scores['hit@3']}, MRR@3={scores['mrr@3']:.4f}, P@3={scores['p@3']:.4f}")
終端機輸出:
評估得分: Hit@3=1.0, MRR@3=0.5000, P@3=0.3333
今天我們完成了檢索系統的量尺:
明天,我們將把這兩者串起來,實作一條可以自動重跑的 Eval Runner 指令,一次跑完所有題庫並印出完整的檢索品質成績單!